package com.farent.solutions.common.discovery;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.logging.Logger;
import java.util.regex.Pattern;

/**
 *
 * @author Farouk A
 */
public class ClassResolver<T>
{
    private static final Pattern STANDARD_SPLIT = Pattern.compile("[\\s,]+");

    /** An instance of Log to use for logging in this class. */
    static Logger log = Logger.getLogger ( ClassResolver.class.getName () );
    
    /** The magic header that indicates a JAR (ZIP) file. */
    private static final byte[] JAR_MAGIC =
    {
        'P', 'K', 3, 4
    };
    /** Regular expression that matches a Java identifier. */
    private static final Pattern JAVA_IDENTIFIER_PATTERN = Pattern.compile ( "\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*" );

    /**
     * A simple interface that specifies how to test classes to determine if they
     * are to be included in the results produced by the ClassFinder.
     */
    public static interface ClassSelectionStrategy
    {
        /**
         * Will be called repeatedly with candidate classes. Must return True if a class
         * is to be included in the results, false otherwise.
         */
        boolean matches ( Class<?> type );
        
        void setClass ( Class<?> type );
    }

    /** The set of matches being accumulated. */
    private Set<Class<? extends T>> matches = new HashSet<Class<? extends T>> ();
    /**
     * The ClassLoader to use when looking for classes. If null then the ClassLoader returned
     * by Thread.currentThread().getContextClassLoader() will be used.
     */
    private ClassLoader classloader;

    /**
     * Provides access to the classes discovered so far. If no calls have been made to
     * any of the {@code find()} methods, this set will be empty.
     *
     * @return the set of classes that have been discovered.
     */
    public Set<Class<? extends T>> getClasses ()
    {
        return matches;
    }

    /**
     * Returns the classloader that will be used for scanning for classes. If no explicit
     * ClassLoader has been set by the calling, the context class loader will be used.
     *
     * @return the ClassLoader that will be used to scan for classes
     */
    public ClassLoader getClassLoader ()
    {
        return classloader == null ? Thread.currentThread ().getContextClassLoader () : classloader;
    }

    /**
     * Sets an explicit ClassLoader that should be used when scanning for classes. If none
     * is set then the context classloader will be used.
     *
     * @param classloader a ClassLoader to use when scanning for classes
     */
    public void setClassLoader ( ClassLoader classloader )
    {
        this.classloader = classloader;
    }

    /**
     * Attempts to discover classes that are assignable to the type provided. In the case
     * that an interface is provided this method will collect implementations. In the case
     * of a non-interface class, subclasses will be collected.  Accumulated classes can be
     * accessed by calling {@link #getClasses()}.
     *
     * @param parent the class of interface to find subclasses or implementations of
     * @param packageNames one or more package names to scan (including subpackages) for classes
     */
    public void discover ( ClassSelectionStrategy selectionStrategy, String... packageNames )
    {
        if ( packageNames == null )
            return ;

        for ( String pkg : packageNames )
        {
            find ( selectionStrategy, pkg );
        }
    }

    /**
     * Scans for classes starting at the package provided and descending into subpackages.
     * Each class is offered up to the CheckClass as it is discovered to test if it returns
     * true for the class is retained.  Accumulated classes can be fetched by calling
     * {@link #getClasses()}.
     *
     * @param checkClass an instance of {@link Test} that will be used to filter classes
     * @param packageName the name of the package from which to start scanning for
     *        classes, e.g. {@code uk.co.card}
     */
    public ClassResolver<T> find ( ClassSelectionStrategy checkClass, String packageName )
    {
        String path = getPackagePath ( packageName );

        try
        {
            List<URL> urls = Collections.list ( getClassLoader ().getResources ( path ) );
            for ( URL url : urls )
            {
                List<String> children = listClassResources ( url, path );
                for ( String child : children )
                {
                    addIfMatching ( checkClass, child );
                }
            }
        } catch ( IOException ioe )
        {
            log.warning ( "Could not read package: " + packageName + " -- " + ioe );
        }

        return this;
    }

    /**
     * Recursively list all resources under the given URL that appear to define a Java class.
     * Matching resources will have a name that ends in ".class"
     *
     * @param url The URL of the parent resource to search.
     * @param path The path with which each matching resource path must begin, relative to the URL.
     * @return A list of matching resources. The list may be empty.
     * @throws IOException
     */
    protected List<String> listClassResources ( URL url, String path ) throws IOException
    {
        InputStream is = null;
        try
        {
            List<String> resources = new ArrayList<String> ();

            // First, try to find the URL of a JAR file containing the requested resource. If a JAR
            // file is found, then we'll list child resources by reading the JAR.
            URL jarUrl = findJarForResource ( url, path );
            if ( jarUrl != null )
            {
                is = jarUrl.openStream ();
                resources = listClassResources ( new JarInputStream ( is ), path );
            } else
            {
                List<String> children = new ArrayList<String> ();
                try
                {
                    if ( isJar ( url ) )
                    {
                        is = url.openStream ();
                        JarInputStream jarInput = new JarInputStream ( is );
                        for ( JarEntry entry; ( entry = jarInput.getNextJarEntry () ) != null; )
                        {
                            if ( isRelevantResource ( entry.getName () ) )
                            {
                                children.add ( entry.getName () );
                            }
                        }
                    } else
                    {
                        is = url.openStream ();
                        BufferedReader reader = new BufferedReader ( new InputStreamReader ( is ) );
                        for ( String line; ( line = reader.readLine () ) != null; )
                        {
                            if ( isRelevantResource ( line ) )
                            {
                                children.add ( line );
                            }
                        }
                    }
                } catch ( FileNotFoundException e )
                {
                    /*
                     * For file URLs the openStream() call might fail, depending on the servlet
                     * container, because directories can't be opened for reading. If that happens,
                     * then list the directory directly instead.
                     */
                    if ( "file".equals ( url.getProtocol () ) )
                    {
                        File file = new File ( url.getFile () );
                        if ( file.isDirectory () )
                        {
                            children = Arrays.asList ( file.list ( new FilenameFilter ()
                            {

                                public boolean accept ( File dir, String name )
                                {
                                    return isRelevantResource ( name );
                                }
                            } ) );
                        }
                    } else
                    {
                        throw e;
                    }
                }

                // The URL prefix to use when recursively listing child resources
                String prefix = url.toExternalForm ();
                if ( !prefix.endsWith ( "/" ) )
                {
                    prefix = prefix + "/";
                }

                // Iterate over each immediate child, adding classes and recursing into directories
                for ( String child : children )
                {
                    String resourcePath = path + "/" + child;
                    if ( child.endsWith ( ".class" ) )
                    {
                        resources.add ( resourcePath );
                    } else
                    {
                        URL childUrl = new URL ( prefix + child );
                        resources.addAll ( listClassResources ( childUrl, resourcePath ) );
                    }
                }
            }

            return resources;
        } finally
        {
            try
            {
                is.close ();
            } catch ( Exception e )
            {
            }
        }
    }

    /**
     * List the names of the entries in the given {@link JarInputStream} that begin with the
     * specified {@code path}. Entries will match with or without a leading slash.
     *
     * @param jar The JAR input stream
     * @param path The leading path to match
     * @return The names of all the matching entries
     * @throws IOException
     */
    protected List<String> listClassResources ( JarInputStream jar, String path ) throws IOException
    {
        // Include the leading and trailing slash when matching names
        if ( !path.startsWith ( "/" ) )
        {
            path = "/" + path;
        }
        if ( !path.endsWith ( "/" ) )
        {
            path = path + "/";
        }

        // Iterate over the entries and collect those that begin with the requested path
        List<String> resources = new ArrayList<String> ();
        for ( JarEntry entry; ( entry = jar.getNextJarEntry () ) != null; )
        {
            if ( !entry.isDirectory () )
            {
                // Add leading slash if it's missing
                String name = entry.getName ();
                if ( !name.startsWith ( "/" ) )
                {
                    name = "/" + name;
                }

                // Check file name
                if ( name.endsWith ( ".class" ) && name.startsWith ( path ) )
                {
                    log.fine ( "Found class file: " + name );
                    resources.add ( name.substring ( 1 ) ); // Trim leading slash
                }
            }
        }
        return resources;
    }

    /**
     * Attempts to deconstruct the given URL to find a JAR file containing the resource referenced
     * by the URL. That is, assuming the URL references a JAR entry, this method will return a URL
     * that references the JAR file containing the entry. If the JAR cannot be located, then this
     * method returns null.
     *
     * @param url The URL of the JAR entry.
     * @param path The path by which the URL was requested from the class loader.
     * @return The URL of the JAR file, if one is found. Null if not.
     * @throws MalformedURLException
     */
    protected URL findJarForResource ( URL url, String path ) throws MalformedURLException
    {

        // If the file part of the URL is itself a URL, then that URL probably points to the JAR
        try
        {
            for ( ;; )
            {
                url = new URL ( url.getFile () );
            }
        } catch ( MalformedURLException e )
        {
            // This will happen at some point and serves a break in the loop
        }

        // Look for the .jar extension and chop off everything after that
        StringBuilder jarUrl = new StringBuilder ( url.toExternalForm () );
        int index = jarUrl.lastIndexOf ( ".jar" );
        if ( index >= 0 )
        {
            jarUrl.setLength ( index + 4 );
        } else
        {
            return null;
        }

        // Try to open and test it
        try
        {
            URL testUrl = new URL ( jarUrl.toString () );
            if ( isJar ( testUrl ) )
            {
                return testUrl;
            } else
            {
                jarUrl.replace ( 0, jarUrl.length (), testUrl.getFile () );
                File file = new File ( jarUrl.toString () );

                // File name might be URL-encoded
                if ( !file.exists () )
                {
                    file = new File ( urlDecode ( jarUrl.toString () ) );
                }

                if ( file.exists () )
                {
                    testUrl = file.toURI ().toURL ();
                    if ( isJar ( testUrl ) )
                    {
                        return testUrl;
                    }
                }
            }
        } catch ( MalformedURLException e )
        {
            log.warning ( "Invalid JAR URL: " + jarUrl );
        }
        return null;
    }

    /**
     * Converts a Java package name to a path that can be looked up with a call to
     * {@link ClassLoader#getResources(String)}.
     *
     * @param packageName The Java package name to convert to a path
     */
    protected static String getPackagePath ( String packageName )
    {
        return packageName == null ? null : packageName.replace ( '.', '/' );
    }

    /**
     * Returns true if the name of a resource (file or directory) is one that matters in the search
     * for classes. Relevant resources would be class files themselves (file names that end with
     * ".class") and directories that might be a Java package name segment (java identifiers).
     *
     * @param resourceName The resource name, without path information
     */
    protected boolean isRelevantResource ( String resourceName )
    {
        return resourceName != null
            && ( resourceName.endsWith ( ".class" ) || JAVA_IDENTIFIER_PATTERN.matcher ( resourceName ).matches () );
    }

    /**
     * Returns true if the resource located at the given URL is a JAR file.
     *
     * @param url The URL of the resource to test.
     */
    protected boolean isJar ( URL url )
    {
        return isJar ( url, new byte[ JAR_MAGIC.length ] );
    }

    /**
     * Returns true if the resource located at the given URL is a JAR file.
     *
     * @param url The URL of the resource to test.
     * @param buffer A buffer into which the first few bytes of the resource are read. The buffer
     *            must be at least the size of {@link #JAR_MAGIC}. (The same buffer may be reused
     *            for multiple calls as an optimization.)
     */
    private  boolean isJar ( URL url, byte[] buffer )
    {
        InputStream is = null;
        try
        {
            is = url.openStream ();
            is.read ( buffer, 0, JAR_MAGIC.length );
            if ( Arrays.equals ( buffer, JAR_MAGIC ) )
            {
                log.fine ( "Found JAR: " + url );
                return true;
            }
        } catch ( Exception e )
        {
            // Failure to read the stream means this is not a JAR
        } finally
        {
            try
            {
                is.close ();
            } catch ( Exception e )
            {
            }
        }

        return false;
    }

    /**
     * Add the class designated by the fully qualified class name provided to the set of
     * resolved classes if and only if it is approved by the CheckClass supplied.
     *
     * @param checkClass the test used to determine if the class matches
     * @param fqn the fully qualified name of a class
     */
    @SuppressWarnings ( "unchecked" )
    protected void addIfMatching ( ClassSelectionStrategy checkClass, String fqn )
    {
        try
        {
            String externalName = fqn.substring ( 0, fqn.indexOf ( '.' ) ).replace ( '/', '.' );
            ClassLoader loader = getClassLoader ();

            Class type = loader.loadClass ( externalName );
            if ( checkClass.matches ( type ) )
            {
                matches.add ( ( Class<T> ) type );
            }
        } catch ( Throwable t )
        {
            log.severe ( "Could not examine class '"
                + fqn + "'"
                + " due to a " + t.getClass ().getName () + " with message: " + t.getMessage () );
        }
    }

    /**
     * URL-decodes {@code value} using the UTF-8 charset. Using this method eliminates the need for
     * a try/catch since UTF-8 is guaranteed to exist.
     *
     * @see URLDecoder#decode(String, String)
     */
    protected static String urlDecode ( String value )
    {
        try
        {
            return URLDecoder.decode ( value, "UTF-8" );
        } catch ( UnsupportedEncodingException e )
        {
            throw new RuntimeException ( "Unsupported encoding?  UTF-8?  That's impossible." );
        }
    }

    /**
     * Splits apart the input String on any whitespace and/or commas. Leading and trailing
     * whitespace are ignored. If a null String is provided as input a zero length array
     * will be returned.
     *
     * @param input the String to split apart
     * @return an array of substrings of the input String based on the split
     */
    protected static String[] standardSplit(String input) {
        if (input == null) {
            return new String[0];
        }
        else {
            return STANDARD_SPLIT.split(input.trim());
        }
    }
}
